# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
#
# This Source Code Form is "Incompatible With Secondary Licenses", as
# defined by the Mozilla Public License, v. 2.0.

package Bugzilla::FlagType;

use 5.14.0;
use strict;
use warnings;

=head1 NAME

Bugzilla::FlagType - A module to deal with Bugzilla flag types.

=head1 SYNOPSIS

FlagType.pm provides an interface to flag types as stored in Bugzilla.
See below for more information.

=head1 NOTES

=over

=item *

Use of private functions/variables outside this module may lead to
unexpected results after an upgrade.  Please avoid using private
functions in other files/modules.  Private functions are functions
whose names start with _ or are specifically noted as being private.

=back

=cut

use Bugzilla::Constants;
use Bugzilla::Error;
use Bugzilla::Util;
use Bugzilla::Group;

use Email::Address;
use List::MoreUtils qw(uniq);

use parent qw(Bugzilla::Object);

###############################
####    Initialization     ####
###############################

use constant DB_TABLE   => 'flagtypes';
use constant LIST_ORDER => 'sortkey, name';

use constant DB_COLUMNS => qw(
  id
  name
  description
  cc_list
  target_type
  sortkey
  is_active
  is_requestable
  is_requesteeble
  is_multiplicable
  grant_group_id
  request_group_id
);

use constant UPDATE_COLUMNS => qw(
  name
  description
  cc_list
  sortkey
  is_active
  is_requestable
  is_requesteeble
  is_multiplicable
  grant_group_id
  request_group_id
);

use constant VALIDATORS => {
  name             => \&_check_name,
  description      => \&_check_description,
  cc_list          => \&_check_cc_list,
  target_type      => \&_check_target_type,
  sortkey          => \&_check_sortkey,
  is_active        => \&Bugzilla::Object::check_boolean,
  is_requestable   => \&Bugzilla::Object::check_boolean,
  is_requesteeble  => \&Bugzilla::Object::check_boolean,
  is_multiplicable => \&Bugzilla::Object::check_boolean,
  grant_group      => \&_check_group,
  request_group    => \&_check_group,
};

use constant UPDATE_VALIDATORS =>
  {grant_group_id => \&_check_group, request_group_id => \&_check_group,};
###############################

sub create {
  my $class = shift;
  my $dbh   = Bugzilla->dbh;

  $dbh->bz_start_transaction();

  $class->check_required_create_fields(@_);
  my $params = $class->run_create_validators(@_);

  # In the DB, only the first character of the target type is stored.
  $params->{target_type} = substr($params->{target_type}, 0, 1);

  # Extract everything which is not a valid column name.
  $params->{grant_group_id}   = delete $params->{grant_group};
  $params->{request_group_id} = delete $params->{request_group};
  my $inclusions = delete $params->{inclusions};
  my $exclusions = delete $params->{exclusions};

  my $flagtype = $class->insert_create_data($params);

  $flagtype->set_clusions({inclusions => $inclusions, exclusions => $exclusions});
  $flagtype->update();

  $dbh->bz_commit_transaction();
  return $flagtype;
}

sub update {
  my $self    = shift;
  my $dbh     = Bugzilla->dbh;
  my $flag_id = $self->id;

  $dbh->bz_start_transaction();
  my $changes = $self->SUPER::update(@_);

  # Update the flaginclusions and flagexclusions tables.
  foreach my $category ('inclusions', 'exclusions') {
    next unless delete $self->{"_update_$category"};

    $dbh->do("DELETE FROM flag$category WHERE type_id = ?", undef, $flag_id);

    my $sth = $dbh->prepare(
      "INSERT INTO flag$category
                                (type_id, product_id, component_id) VALUES (?, ?, ?)"
    );

    foreach my $prod_comp (values %{$self->{$category}}) {
      my ($prod_id, $comp_id) = split(':', $prod_comp);
      $prod_id ||= undef;
      $comp_id ||= undef;
      $sth->execute($flag_id, $prod_id, $comp_id);
    }
    $changes->{$category} = [0, 1];
  }

  # Clear existing flags for bugs/attachments in categories no longer on
  # the list of inclusions or that have been added to the list of exclusions.
  my $flag_ids = $dbh->selectcol_arrayref(
    'SELECT DISTINCT flags.id
                                               FROM flags
                                         INNER JOIN bugs
                                                 ON flags.bug_id = bugs.bug_id
                                          LEFT JOIN flaginclusions AS i
                                                 ON (flags.type_id = i.type_id
                                                     AND (bugs.product_id = i.product_id
                                                          OR i.product_id IS NULL)
                                                     AND (bugs.component_id = i.component_id
                                                          OR i.component_id IS NULL))
                                              WHERE flags.type_id = ?
                                                AND i.type_id IS NULL', undef,
    $self->id
  );
  Bugzilla::Flag->force_retarget($flag_ids);

  $flag_ids = $dbh->selectcol_arrayref(
    'SELECT DISTINCT flags.id
                                            FROM flags
                                      INNER JOIN bugs
                                              ON flags.bug_id = bugs.bug_id
                                      INNER JOIN flagexclusions AS e
                                              ON flags.type_id = e.type_id
                                           WHERE flags.type_id = ?
                                             AND (bugs.product_id = e.product_id
                                                  OR e.product_id IS NULL)
                                             AND (bugs.component_id = e.component_id
                                                  OR e.component_id IS NULL)',
    undef, $self->id
  );
  Bugzilla::Flag->force_retarget($flag_ids);

  # Silently remove requestees from flags which are no longer
  # specifically requestable.
  if (!$self->is_requesteeble) {
    my $ids
      = $dbh->selectcol_arrayref(
      'SELECT id FROM flags WHERE type_id = ? AND requestee_id IS NOT NULL',
      undef, $self->id);

    if (@$ids) {
      $dbh->do(
        'UPDATE flags SET requestee_id = NULL WHERE ' . $dbh->sql_in('id', $ids));
      foreach my $id (@$ids) {
        Bugzilla->memcached->clear({table => 'flags', id => $id});
      }
    }
  }

  $dbh->bz_commit_transaction();
  return $changes;
}

###############################
####      Accessors      ######
###############################

=head2 METHODS

=over

=item C<id>

Returns the ID of the flagtype.

=item C<name>

Returns the name of the flagtype.

=item C<description>

Returns the description of the flagtype.

=item C<cc_list>

Returns the concatenated CC list for the flagtype, as a single string.

=item C<target_type>

Returns whether the flagtype applies to bugs or attachments.

=item C<is_active>

Returns whether the flagtype is active or disabled. Flags being
in a disabled flagtype are not deleted. It only prevents you from
adding new flags to it.

=item C<is_requestable>

Returns whether you can request for the given flagtype
(i.e. whether the '?' flag is available or not).

=item C<is_requesteeble>

Returns whether you can ask someone specifically or not.

=item C<is_multiplicable>

Returns whether you can have more than one flag for the given
flagtype in a given bug/attachment.

=item C<sortkey>

Returns the sortkey of the flagtype.

=back

=cut

sub id          { return $_[0]->{'id'}; }
sub name        { return $_[0]->{'name'}; }
sub description { return $_[0]->{'description'}; }
sub cc_list     { return $_[0]->{'cc_list'}; }
sub target_type { return $_[0]->{'target_type'} eq 'b' ? 'bug' : 'attachment'; }
sub is_active   { return $_[0]->{'is_active'}; }
sub is_requestable   { return $_[0]->{'is_requestable'}; }
sub is_requesteeble  { return $_[0]->{'is_requesteeble'}; }
sub is_multiplicable { return $_[0]->{'is_multiplicable'}; }
sub sortkey          { return $_[0]->{'sortkey'}; }
sub request_group_id { return $_[0]->{'request_group_id'}; }
sub grant_group_id   { return $_[0]->{'grant_group_id'}; }

################################
# Validators
################################

sub _check_name {
  my ($invocant, $name) = @_;

  $name = trim($name);
  ($name && $name !~ /[\s,]/ && length($name) <= 50)
    || ThrowUserError('flag_type_name_invalid', {name => $name});
  return $name;
}

sub _check_description {
  my ($invocant, $desc) = @_;

  $desc = trim($desc);
  $desc || ThrowUserError('flag_type_description_invalid');
  return $desc;
}

sub _check_cc_list {
  my ($invocant, $cc_list) = @_;

  length($cc_list) <= 200
    || ThrowUserError('flag_type_cc_list_invalid', {cc_list => $cc_list});

  my @addresses = split(/[,\s]+/, $cc_list);
  my $addr_spec = $Email::Address::addr_spec;

  # We do not call check_email_syntax() because these addresses are not
  # required to match 'emailregexp'.
  foreach my $address (@addresses) {
    ($address !~ /\P{ASCII}/ && $address =~ /^$addr_spec$/)
      || ThrowUserError('illegal_email_address', {addr => $address, default => 1});
  }
  return $cc_list;
}

sub _check_target_type {
  my ($invocant, $target_type) = @_;

  ($target_type eq 'bug' || $target_type eq 'attachment')
    || ThrowCodeError('flag_type_target_type_invalid',
    {target_type => $target_type});
  return $target_type;
}

sub _check_sortkey {
  my ($invocant, $sortkey) = @_;

  (detaint_natural($sortkey) && $sortkey <= MAX_SMALLINT)
    || ThrowUserError('flag_type_sortkey_invalid', {sortkey => $sortkey});
  return $sortkey;
}

sub _check_group {
  my ($invocant, $group) = @_;
  return unless $group;

  trick_taint($group);
  $group = Bugzilla::Group->check($group);
  return $group->id;
}

###############################
####       Methods         ####
###############################

sub set_name                        { $_[0]->set('name',             $_[1]); }
sub set_description                 { $_[0]->set('description',      $_[1]); }
sub set_cc_list                     { $_[0]->set('cc_list',          $_[1]); }
sub set_sortkey                     { $_[0]->set('sortkey',          $_[1]); }
sub set_is_active                   { $_[0]->set('is_active',        $_[1]); }
sub set_is_requestable              { $_[0]->set('is_requestable',   $_[1]); }
sub set_is_specifically_requestable { $_[0]->set('is_requesteeble',  $_[1]); }
sub set_is_multiplicable            { $_[0]->set('is_multiplicable', $_[1]); }
sub set_grant_group                 { $_[0]->set('grant_group_id',   $_[1]); }
sub set_request_group               { $_[0]->set('request_group_id', $_[1]); }

sub set_clusions {
  my ($self, $list) = @_;
  my $user = Bugzilla->user;
  my %products;
  my $params = {};

  # If the user has editcomponents privs, then we only need to make sure
  # that the product exists.
  if ($user->in_group('editcomponents')) {
    $params->{allow_inaccessible} = 1;
  }

  foreach my $category (keys %$list) {
    my %clusions;
    my %clusions_as_hash;

    foreach my $prod_comp (@{$list->{$category} || []}) {
      my ($prod_id, $comp_id) = split(':', $prod_comp);
      my $prod_name = '__Any__';
      my $comp_name = '__Any__';

      # Does the product exist?
      if ($prod_id) {
        detaint_natural($prod_id)
          || ThrowCodeError('param_must_be_numeric',
          {function => 'Bugzilla::FlagType::set_clusions'});

        if (!$products{$prod_id}) {
          $params->{id} = $prod_id;
          $products{$prod_id} = Bugzilla::Product->check($params);
        }
        $prod_name = $products{$prod_id}->name;

        # Does the component belong to this product?
        if ($comp_id) {
          detaint_natural($comp_id)
            || ThrowCodeError('param_must_be_numeric',
            {function => 'Bugzilla::FlagType::set_clusions'});

          my ($component) = grep { $_->id == $comp_id } @{$products{$prod_id}->components}
            or ThrowUserError('product_unknown_component',
            {product => $prod_name, comp_id => $comp_id});
          $comp_name = $component->name;
        }
        else {
          $comp_id = 0;
        }
      }
      else {
        $prod_id = 0;
        $comp_id = 0;
      }
      $clusions{"$prod_name:$comp_name"} = "$prod_id:$comp_id";
      $clusions_as_hash{$prod_id}->{$comp_id} = 1;
    }

    # Check the user has the editcomponent permission on products that are changing
    if (!$user->in_group('editcomponents')) {
      my $current_clusions = $self->$category;
      my ($removed, $added)
        = diff_arrays([values %$current_clusions], [values %clusions]);
      my @changed_product_ids = uniq map { substr($_, 0, index($_, ':')) } @$removed,
        @$added;
      foreach my $product_id (@changed_product_ids) {
        $user->in_group('editcomponents', $product_id)
          || ThrowUserError('product_access_denied',
          {name => $products{$product_id}->name});
      }
    }

    # Set the changes
    $self->{$category}             = \%clusions;
    $self->{"${category}_as_hash"} = \%clusions_as_hash;
    $self->{"_update_$category"}   = 1;
  }
}

=pod

=over

=item C<grant_list>

Returns a reference to an array of users who have permission to grant this flag type.
The arrays are populated with hashrefs containing the login, identity and visibility of users.

=item C<grant_group>

Returns the group (as a Bugzilla::Group object) in which a user
must be in order to grant or deny a request.

=item C<request_group>

Returns the group (as a Bugzilla::Group object) in which a user
must be in order to request or clear a flag.

=item C<flag_count>

Returns the number of flags belonging to the flagtype.

=item C<inclusions>

Return a hash of product/component IDs and names
explicitly associated with the flagtype.

=item C<exclusions>

Return a hash of product/component IDs and names
explicitly excluded from the flagtype.

=back

=cut

sub grant_list {
  my $self = shift;
  require Bugzilla::User;
  my @custusers;
  my @allusers = @{Bugzilla->user->get_userlist};
  foreach my $user (@allusers) {
    my $user_obj = new Bugzilla::User({name => $user->{login}});
    push(@custusers, $user) if $user_obj->can_set_flag($self);
  }
  return \@custusers;
}

sub grant_group {
  my $self = shift;

  if (!defined $self->{'grant_group'} && $self->{'grant_group_id'}) {
    $self->{'grant_group'} = new Bugzilla::Group($self->{'grant_group_id'});
  }
  return $self->{'grant_group'};
}

sub request_group {
  my $self = shift;

  if (!defined $self->{'request_group'} && $self->{'request_group_id'}) {
    $self->{'request_group'} = new Bugzilla::Group($self->{'request_group_id'});
  }
  return $self->{'request_group'};
}

sub flag_count {
  my $self = shift;

  if (!defined $self->{'flag_count'}) {
    $self->{'flag_count'} = Bugzilla->dbh->selectrow_array(
      'SELECT COUNT(*) FROM flags
                                            WHERE type_id = ?', undef, $self->{'id'}
    );
  }
  return $self->{'flag_count'};
}

sub inclusions {
  my $self = shift;

  if (!defined $self->{inclusions}) {
    ($self->{inclusions}, $self->{inclusions_as_hash})
      = get_clusions($self->id, 'in');
  }
  return $self->{inclusions};
}

sub inclusions_as_hash {
  my $self = shift;

  $self->inclusions unless defined $self->{inclusions_as_hash};
  return $self->{inclusions_as_hash};
}

sub exclusions {
  my $self = shift;

  if (!defined $self->{exclusions}) {
    ($self->{exclusions}, $self->{exclusions_as_hash})
      = get_clusions($self->id, 'ex');
  }
  return $self->{exclusions};
}

sub exclusions_as_hash {
  my $self = shift;

  $self->exclusions unless defined $self->{exclusions_as_hash};
  return $self->{exclusions_as_hash};
}

######################################################################
# Public Functions
######################################################################

=pod

=head1 PUBLIC FUNCTIONS/METHODS

=over

=item C<get_clusions($id, $type)>

Return a hash of product/component IDs and names
associated with the flagtype:
$clusions{'product_name:component_name'} = "product_ID:component_ID"

=back

=cut

sub get_clusions {
  my ($id, $type) = @_;
  my $dbh = Bugzilla->dbh;

  my $list = $dbh->selectall_arrayref(
    "SELECT products.id, products.name,
                                         components.id, components.name
                                    FROM flagtypes
                              INNER JOIN flag${type}clusions
                                      ON flag${type}clusions.type_id = flagtypes.id
                               LEFT JOIN products
                                      ON flag${type}clusions.product_id = products.id
                               LEFT JOIN components
                                      ON flag${type}clusions.component_id = components.id
                                   WHERE flagtypes.id = ?", undef, $id
  );
  my (%clusions, %clusions_as_hash);
  foreach my $data (@$list) {
    my ($product_id, $product_name, $component_id, $component_name) = @$data;
    $product_id     ||= 0;
    $product_name   ||= "__Any__";
    $component_id   ||= 0;
    $component_name ||= "__Any__";
    $clusions{"$product_name:$component_name"} = "$product_id:$component_id";
    $clusions_as_hash{$product_id}->{$component_id} = 1;
  }
  return (\%clusions, \%clusions_as_hash);
}

=pod

=over

=item C<match($criteria)>

Queries the database for flag types matching the given criteria
and returns a list of matching flagtype objects.

=back

=cut

sub match {
  my ($criteria) = @_;
  my $dbh = Bugzilla->dbh;

  # Depending on the criteria, we may have to append additional tables.
  my $tables = [DB_TABLE];
  my @criteria = sqlify_criteria($criteria, $tables);
  $tables   = join(' ',     @$tables);
  $criteria = join(' AND ', @criteria);

  my $flagtype_ids
    = $dbh->selectcol_arrayref("SELECT id FROM $tables WHERE $criteria");

  return Bugzilla::FlagType->new_from_list($flagtype_ids);
}

=pod

=over

=item C<count($criteria)>

Returns the total number of flag types matching the given criteria.

=back

=cut

sub count {
  my ($criteria) = @_;
  my $dbh = Bugzilla->dbh;

  # Depending on the criteria, we may have to append additional tables.
  my $tables = [DB_TABLE];
  my @criteria = sqlify_criteria($criteria, $tables);
  $tables   = join(' ',     @$tables);
  $criteria = join(' AND ', @criteria);

  my $count = $dbh->selectrow_array(
    "SELECT COUNT(flagtypes.id)
                                       FROM $tables WHERE $criteria"
  );
  return $count;
}

######################################################################
# Private Functions
######################################################################

# Converts a hash of criteria into a list of SQL criteria.
# $criteria is a reference to the criteria (field => value),
# $tables is a reference to an array of tables being accessed
# by the query.

sub sqlify_criteria {
  my ($criteria, $tables) = @_;
  my $dbh = Bugzilla->dbh;

  # the generated list of SQL criteria; "1=1" is a clever way of making sure
  # there's something in the list so calling code doesn't have to check list
  # size before building a WHERE clause out of it
  my @criteria = ("1=1");

  if ($criteria->{name}) {
    if (ref($criteria->{name}) eq 'ARRAY') {
      my @names = map { $dbh->quote($_) } @{$criteria->{name}};

      # Detaint data as we have quoted it.
      foreach my $name (@names) {
        trick_taint($name);
      }
      push @criteria, $dbh->sql_in('flagtypes.name', \@names);
    }
    else {
      my $name = $dbh->quote($criteria->{name});
      trick_taint($name);    # Detaint data as we have quoted it.
      push(@criteria, "flagtypes.name = $name");
    }
  }
  if ($criteria->{target_type}) {

    # The target type is stored in the database as a one-character string
    # ("a" for attachment and "b" for bug), but this function takes complete
    # names ("attachment" and "bug") for clarity, so we must convert them.
    my $target_type = $criteria->{target_type} eq 'bug' ? 'b' : 'a';
    push(@criteria, "flagtypes.target_type = '$target_type'");
  }
  if (exists($criteria->{is_active})) {
    my $is_active = $criteria->{is_active} ? "1" : "0";
    push(@criteria, "flagtypes.is_active = $is_active");
  }
  if ($criteria->{product_id}) {
    my $product_id = $criteria->{product_id};
    detaint_natural($product_id)
      || ThrowCodeError('bad_arg',
      {argument => 'product_id', function => 'Bugzilla::FlagType::sqlify_criteria'});

    # Add inclusions to the query, which simply involves joining the table
    # by flag type ID and target product/component.
    push(@$tables,  "INNER JOIN flaginclusions AS i ON flagtypes.id = i.type_id");
    push(@criteria, "(i.product_id = $product_id OR i.product_id IS NULL)");

    # Add exclusions to the query, which is more complicated.  First of all,
    # we do a LEFT JOIN so we don't miss flag types with no exclusions.
    # Then, as with inclusions, we join on flag type ID and target product/
    # component.  However, since we want flag types that *aren't* on the
    # exclusions list, we add a WHERE criteria to use only records with
    # NULL exclusion type, i.e. without any exclusions.
    my $join_clause = "flagtypes.id = e.type_id ";

    my $addl_join_clause = "";
    if ($criteria->{component_id}) {
      my $component_id = $criteria->{component_id};
      detaint_natural($component_id) || ThrowCodeError('bad_arg',
        {argument => 'component_id', function => 'Bugzilla::FlagType::sqlify_criteria'}
      );

      push(@criteria, "(i.component_id = $component_id OR i.component_id IS NULL)");
      $join_clause
        .= "AND (e.component_id = $component_id OR e.component_id IS NULL) ";
    }
    else {
      $addl_join_clause
        = "AND e.component_id IS NULL OR (i.component_id = e.component_id) ";
    }
    $join_clause
      .= "AND ((e.product_id = $product_id $addl_join_clause) OR e.product_id IS NULL)";

    push(@$tables,  "LEFT JOIN flagexclusions AS e ON ($join_clause)");
    push(@criteria, "e.type_id IS NULL");
  }
  if ($criteria->{group}) {
    my $gid = $criteria->{group};
    detaint_natural($gid)
      || ThrowCodeError('bad_arg',
      {argument => 'group', function => 'Bugzilla::FlagType::sqlify_criteria'});

    push(@criteria,
      "(flagtypes.grant_group_id = $gid " . " OR flagtypes.request_group_id = $gid)");
  }

  return @criteria;
}

1;

=head1 B<Methods in need of POD>

=over

=item exclusions_as_hash

=item request_group_id

=item set_is_active

=item set_is_multiplicable

=item inclusions_as_hash

=item set_sortkey

=item grant_group_id

=item set_cc_list

=item set_request_group

=item set_name

=item set_is_specifically_requestable

=item set_grant_group

=item create

=item set_clusions

=item set_description

=item set_is_requestable

=item update

=back
